const debug = require('debug')('codeceptjs:recorder')
const promiseRetry = require('promise-retry')
const chalk = require('chalk')
const { printObjectProperties } = require('./utils')
const { log } = require('./output')
const { TimeoutError } = require('./timeout')
const MAX_TASKS = 100

let promise
let running = false
let errFn
let queueId = 0
let sessionId = null
let sessionStack = [] // Stack to support nested sessions
let asyncErr = null
let ignoredErrs = []

let tasks = []
let oldPromises = []

const defaultRetryOptions = {
  retries: 0,
  minTimeout: 150,
  maxTimeout: 10000,
}

/**
 * Singleton object to record all test steps as promises and run them in chain.
 * @alias recorder
 * @interface
 */
module.exports = {
  /**
   * @type {Array<Object<string, *>>}
   * @inner
   */
  retries: [],

  /**
   * Start recording promises
   *
   * @api
   * @inner
   */
  start() {
    debug('Starting recording promises')
    running = true
    asyncErr = null
    errFn = null
    this.reset()
  },

  /**
   * @return {boolean}
   * @inner
   */
  isRunning() {
    return running
  },

  /**
   * @return {void}
   * @inner
   */
  startUnlessRunning() {
    if (!this.isRunning()) {
      this.start()
    }
  },

  /**
   * Add error handler to catch rejected promises
   *
   * @api
   * @param {function} fn
   * @inner
   */
  errHandler(fn) {
    errFn = fn
  },

  /**
   * Stops current promise chain, calls `catch`.
   * Resets recorder to initial state.
   *
   * @api
   * @inner
   */
  reset() {
    if (promise && running) this.catch()
    queueId++
    sessionId = null
    sessionStack = [] // Clear the session stack
    asyncErr = null
    log(`${currentQueue()} Starting recording promises`)
    promise = Promise.resolve()
    oldPromises = []
    tasks = []
    ignoredErrs = []
    this.session.running = false
    // reset this retries makes the retryFailedStep plugin won't work if there is Before/BeforeSuit block due to retries is undefined on Scenario
    // this.retries = [];
  },

  /**
   * @name CodeceptJS.recorder~session
   * @type {CodeceptJS.RecorderSession}
   * @inner
   */

  /**
   * @alias RecorderSession
   * @interface
   */
  session: {
    /**
     * @type {boolean}
     * @inner
     */
    running: false,

    /**
     * @param {string} name
     * @inner
     */
    start(name) {
      if (sessionId) {
        debug(`${currentQueue()}Session already started as ${sessionId}, nesting session ${name}`)
        // Push current session to stack instead of restoring it
        sessionStack.push({
          id: sessionId,
          promise: promise,
          running: this.running,
        })
      }
      debug(`${currentQueue()}Starting <${name}> session`)
      tasks.push('--->')
      oldPromises.push(promise)
      this.running = true
      sessionId = name
      promise = Promise.resolve()
    },

    /**
     * @param {string} [name]
     * @inner
     */
    restore(name) {
      tasks.push('<---')
      debug(`${currentQueue()}Finalize <${name}> session`)
      this.running = false
      this.catch(errFn)
      promise = promise.then(() => oldPromises.pop())

      // Restore parent session from stack if available
      if (sessionStack.length > 0) {
        const parentSession = sessionStack.pop()
        sessionId = parentSession.id
        this.running = parentSession.running
        debug(`${currentQueue()}Restored parent session <${sessionId}>`)
      } else {
        sessionId = null
      }
    },

    /**
     * @param {function} fn
     * @inner
     */
    catch(fn) {
      promise = promise.catch(fn)
    },
  },

  /**
   * Adds a promise to a chain.
   * Promise description should be passed as first parameter.
   *
   * @param {string|function} taskName
   * @param {function} [fn]
   * @param {boolean} [force=false]
   * @param {boolean} [retry]
   *     undefined: `add(fn)` -> `false` and `add('step',fn)` -> `true`
   *     true: it will retries if `retryOpts` set.
   *     false: ignore `retryOpts` and won't retry.
   * @param {number} [timeout]
   * @return {Promise<*>}
   * @inner
   */
  add(taskName, fn = undefined, force = false, retry = undefined, timeout = undefined) {
    if (typeof taskName === 'function') {
      fn = taskName
      taskName = fn.toString()
      if (retry === undefined) retry = false
    }
    if (retry === undefined) retry = true
    if (!running && !force) {
      return Promise.resolve()
    }
    tasks.push(taskName)
    debug(chalk.gray(`${currentQueue()} Queued | ${taskName}`))

    return (promise = Promise.resolve(promise).then(res => {
      // prefer options for non-conditional retries
      const retryOpts = this.retries
        .sort((r1, r2) => r1.when && !r2.when)
        .slice(-1)
        .pop()
      // no retries or unnamed tasks
      debug(`${currentQueue()} Running | ${taskName} | Timeout: ${timeout || 'None'}`)
      if (retryOpts) debug(`${currentQueue()} Retry opts`, JSON.stringify(retryOpts))

      if (!retryOpts || !taskName || !retry) {
        const [promise, timer] = getTimeoutPromise(timeout, taskName)
        return Promise.race([promise, Promise.resolve(res).then(fn)]).finally(() => clearTimeout(timer))
      }

      const retryRules = this.retries.slice().reverse()
      return promiseRetry(Object.assign(defaultRetryOptions, retryOpts), (retry, number) => {
        if (number > 1) log(`${currentQueue()}Retrying... Attempt #${number}`)
        const [promise, timer] = getTimeoutPromise(timeout, taskName)
        return Promise.race([promise, Promise.resolve(res).then(fn)])
          .finally(() => clearTimeout(timer))
          .catch(err => {
            if (ignoredErrs.includes(err)) return
            for (const retryObj of retryRules) {
              if (!retryObj.when) return retry(err)
              if (retryObj.when && retryObj.when(err)) return retry(err)
            }
            throw err
          })
      })
    }))
  },

  /**
   * @param {*} opts
   * @return {*}
   * @inner
   */
  retry(opts) {
    if (!promise) return

    if (opts === null) {
      opts = {}
    }
    if (Number.isInteger(opts)) {
      opts = { retries: opts }
    }
    return this.add(() => this.retries.push(opts))
  },

  /**
   * @param {function} [customErrFn]
   * @return {Promise<*>}
   * @inner
   */
  catch(customErrFn) {
    const fnDescription = customErrFn
      ?.toString()
      ?.replace(/\s{2,}/g, ' ')
      .replace(/\n/g, ' ')
      ?.slice(0, 50)
    debug(chalk.gray(`${currentQueue()} Queued | catch with error handler ${fnDescription || ''}`))
    return (promise = promise.catch(err => {
      log(`${currentQueue()}Error | ${err} ${fnDescription}...`)
      if (!(err instanceof Error)) {
        // strange things may happen
        err = new Error(`[Wrapped Error] ${printObjectProperties(err)}`) // we should be prepared for them
      }
      if (customErrFn) {
        customErrFn(err)
      } else if (errFn) {
        errFn(err)
      }
      this.stop()
    }))
  },

  /**
   * @param {function} customErrFn
   * @return {Promise<*>}
   * @inner
   */
  catchWithoutStop(customErrFn) {
    const fnDescription = customErrFn
      ?.toString()
      ?.replace(/\s{2,}/g, ' ')
      .replace(/\n/g, ' ')
      ?.slice(0, 50)
    return (promise = promise.catch(err => {
      if (ignoredErrs.includes(err)) return // already caught
      log(`${currentQueue()} Error (Non-Terminated) | ${err} | ${fnDescription || ''}...`)
      if (!(err instanceof Error)) {
        // strange things may happen
        err = new Error(`[Wrapped Error] ${JSON.stringify(err)}`) // we should be prepared for them
      }
      if (customErrFn) {
        return customErrFn(err)
      }
    }))
  },

  /**
   * Adds a promise which throws an error into a chain
   *
   * @api
   * @param {*} err
   * @inner
   */

  throw(err) {
    if (ignoredErrs.includes(err)) return promise // already caught
    return this.add(`throw error: ${err.message}`, () => {
      if (ignoredErrs.includes(err)) return // already caught
      throw err
    })
  },

  ignoreErr(err) {
    ignoredErrs.push(err)
  },

  /**
   * @param {*} err
   * @inner
   */
  saveFirstAsyncError(err) {
    if (asyncErr === null) {
      asyncErr = err
    }
  },

  /**
   * @return {*}
   * @inner
   */
  getAsyncErr() {
    return asyncErr
  },

  /**
   * @return {void}
   * @inner
   */
  cleanAsyncErr() {
    asyncErr = null
  },

  /**
   * Stops recording promises
   * @api
   * @inner
   */
  stop() {
    debug(this.toString())
    log(`${currentQueue()} Stopping recording promises`)
    running = false
  },

  /**
   * Get latest promise in chain.
   *
   * @api
   * @return {Promise<*>}
   * @inner
   */
  promise() {
    return promise
  },

  /**
   * Get a list of all chained tasks
   * @return {string}
   * @inner
   */
  scheduled() {
    return tasks.slice(-MAX_TASKS).join('\n')
  },

  /**
   * Get the queue id
   * @return {number}
   * @inner
   */
  getQueueId() {
    return queueId
  },

  /**
   * Get a state of current queue and tasks
   * @return {string}
   * @inner
   */
  toString() {
    return `Queue: ${currentQueue()}\n\nTasks: ${this.scheduled()}`
  },

  /**
   * Get current session ID
   * @return {string|null}
   * @inner
   */
  getCurrentSessionId() {
    return sessionId
  },
}

function getTimeoutPromise(timeoutMs, taskName) {
  let timer
  if (timeoutMs) debug(`Timing out in ${timeoutMs}ms`)
  return [
    new Promise((done, reject) => {
      timer = setTimeout(() => {
        reject(new TimeoutError(`Action ${taskName} was interrupted on timeout ${timeoutMs}ms`))
      }, timeoutMs || 2e9)
    }),
    timer,
  ]
}

function currentQueue() {
  let session = ''
  if (sessionId) session = `<${sessionId}> `
  return `[${queueId}] ${session}`
}
